[PAR-848] Configurable Express Button Styles - #69
Conversation
Only the Merchant Portal and the rendered button know the attribute names, so the model never inspects the string and the parameter is defaulted, leaving existing construction sites untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… entity A non-string or absent persisted value degrades to null rather than throwing, so one bad row cannot brick the whole settings load. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the webhook Only JSON well-formedness is checked. Validating attribute names or values here would put this package and every deployed plugin back in the release path for each new style property. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carried on the availability responses rather than added to the integration interface, which would fatal every deployed plugin. Resolved only when a button actually renders: the service already loads the same settings to check the page toggle, so an unconditional read would double the storage hits on every product and cart page view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty string is not well-formed JSON, so clearing the field returned 400 and left the previous style live with no way to revert. It now means the same as an absent value. The new response parameters are defaulted so a plugin constructing them directly does not fatal on upgrade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y shape The persisted array was rebuilt by hand although the domain model already produces it, so adding one field meant editing two files and the copies could drift apart unnoticed. The sibling banner settings entity already delegates the same way. An empty style now means unset in the model rather than in one of its callers, so every writer gets the rule instead of just the webhook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scoping held, but nothing asserted it, and a configuration leaking between merchant stores is the kind of defect worth a test rather than a proof. Removing the store filter from the repository makes this fail with one store reading another's style. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
m1k3lm
left a comment
There was a problem hiding this comment.
Automated review of the Express Checkout button-style changes — 14 findings, ordered most severe first in the threads below.
Worth blocking on:
- A partial webhook payload silently wipes a merchant's saved
buttonStyle. - The merchant-controlled style blob reaches the storefront unescaped, unvalidated and unbounded.
- The well-formed-JSON invariant is enforced at exactly one edge; the domain model and entity accept anything.
The rest: a doubled settings query on the storefront hot path, two tests that don't prove what they claim, and a cluster of DRY/altitude cleanups.
Generated with Claude Code
| } | ||
|
|
||
| return new self($configs); | ||
| $buttonStyle = $payload['buttonStyle'] ?? null; |
There was a problem hiding this comment.
🐛 correctness
A save-express-checkout-settings webhook payload that omits buttonStyle silently wipes the merchant's stored style instead of leaving it untouched.
$payload['buttonStyle'] ?? null cannot distinguish "key absent" from "explicitly cleared", so transformToDomainModel() builds ExpressCheckoutSettings($configs, null) and setExpressCheckoutSettings() upserts it.
A merchant configures a style, then any client that posts only {topic, expressCheckoutConfigs} — an older portal build, a targeted page-enable/disable call, a retried partial payload — resets buttonStyle to null and the storefront button reverts to defaults with no error.
The PR's own test at ConfigurationWebhookAPITest.php:2303 (assertNull($persisted->getButtonStyle()) after a payload with no buttonStyle) locks this behaviour in.
There was a problem hiding this comment.
Not changing this, but flagging it as a product decision rather than a bug.
save-express-checkout-settings is a full-replace save: expressCheckoutConfigs behaves exactly the same way, so a payload that omits it clears the configs too. Under those semantics buttonStyle going back to null when it is absent is correct, and the test at ConfigurationWebhookAPITest.php:2303 documents that intent rather than locking a defect.
If the topic is meant to be a partial patch instead, then this is a real bug and the fix is distinguishing "key absent" from "explicitly cleared" — but that is a contract question for the portal (sequra/merchant-portal-frontend#1731), not something to guess at here. Leaving the thread open for that.
| return ['available' => $this->available]; | ||
| return [ | ||
| 'available' => $this->available, | ||
| 'buttonStyle' => $this->buttonStyle, |
There was a problem hiding this comment.
🔒 security
A merchant-controlled string is echoed verbatim into the public storefront availability response with no escaping, no content validation and no length bound, making every deployed plugin solely responsible for preventing stored XSS.
ConfigurationWebhookAPITest.php:2348 explicitly asserts that '{"attributeThisReleaseNeverHeardOf":"<script>x</script>", ...}' survives the round trip untouched. That string is persisted and later returned by isAvailable()/isAvailableForGuest().
Any integration that interpolates buttonStyle into an inline <style>/<script>/attribute rather than passing it through JSON.parse + the CDN allow-list gets stored XSS on every product and cart page.
The core has the choke point here (one toArray()) and does nothing with it. At minimum re-encode via json_encode(json_decode(...)) to strip anything that isn't valid JSON structure, and cap the length.
There was a problem hiding this comment.
Partly taken, partly declined — leaving open for your call.
Taken: the length bound is in (62d572a), and the style must now decode to a JSON object, so the blob is no longer unbounded or structurally arbitrary.
Declined: the json_encode(json_decode(...)) re-encode. It does not sanitise values — PHP does not escape < by default, and with JSON_HEX_TAG it becomes \u003C, which JSON.parse turns straight back into <. So it only helps a plugin that interpolates the blob into an inline <script>, and that plugin has larger problems than this field.
On the escaping premise: the validation exists, in integration-assets#669, and it is a whitelist at the point of use — five known keys, a hex regex for colours, a token map for the radius, a clamped font size, all applied through style.setProperty and never through the markup template. The attributeThisReleaseNeverHeardOf value in that test is not one of the five keys, so it is dropped before its value is ever read; put <script> in backgroundColor and it fails the regex. The blob reaching the plugin as a data- attribute still needs ordinary attribute escaping, which esc_attr / escapeHtmlAttr already do for every attribute.
The core deliberately does not know the key names: they are owned by the portal and the button page so that adding one does not need a release of this library and an update of every installed plugin. Checking the shape (object, bounded) is schema-agnostic and compatible with that; checking the contents would not be.
| return $config->toArray(); | ||
| }, $this->expressCheckoutSettings->getExpressCheckoutConfigs()), | ||
| ]; | ||
| $data['expressCheckoutSettings'] = $this->expressCheckoutSettings->toArray(); |
There was a problem hiding this comment.
🐛 correctness
Replacing the entity's explicit persistence mapping with $this->expressCheckoutSettings->toArray() couples the on-disk storage format to the domain model's serialization, so any future field added to the domain silently changes what is written to storage.
The deleted code spelled out exactly which keys are persisted.
Now, if someone adds a derived / presentation field to ExpressCheckoutSettings::toArray() (a resolved default style, a computed enabledPages list), it is written into every stored row without anyone touching the DataAccess layer — and inflate(), which reads only expressCheckoutConfigs and buttonStyle, silently drops it on the next load, producing an asymmetric round trip.
Keep the entity's mapping explicit, or add a matching ExpressCheckoutSettings::fromArray() so the two halves stay symmetric by construction.
There was a problem hiding this comment.
Declined. The asymmetry is real, but there is no defect today, and the explicit mapping was removed on purpose in 2fbc9c1 to stop the same field being spelled out in three places. Reinstating it, or adding a fromArray() to pair with it, is more code for a hypothetical field. Noted as a footgun rather than fixed.
| /** | ||
| * @var string|null | ||
| */ | ||
| protected $buttonStyle; |
There was a problem hiding this comment.
♻️ simplification
GuestExpressCheckoutAvailabilityResponse now duplicates a third field ($buttonStyle) already declared identically in ExpressCheckoutAvailabilityResponse, along with its docblock, constructor assignment and toArray() entry.
The two classes now hold identical protected $available + protected $buttonStyle declarations, identical @var string|null docblocks, identical assignments and identical 'buttonStyle' => $this->buttonStyle lines — the guest response is ExpressCheckoutAvailabilityResponse plus one array field.
A fourth style-related field means editing both files again, and a fix applied to only one (e.g. re-encoding the blob, per the escaping finding) silently leaves the guest endpoint unprotected.
Have the guest response extend ExpressCheckoutAvailabilityResponse and add only availableCountries.
There was a problem hiding this comment.
Declined. The argument that carried this one was that a fix applied to one response would leave the other unprotected — and that dissolves now that the validation lives in the domain model rather than in either DTO. What is left is cosmetic duplication of two fields.
| $configs = $this->expressCheckoutSettings | ||
| ? $this->expressCheckoutSettings->getExpressCheckoutConfigs() | ||
| : []; | ||
| $buttonStyle = $this->expressCheckoutSettings |
There was a problem hiding this comment.
♻️ simplification
GetExpressCheckoutSettingsResponse::toArray() hand-rebuilds the settings shape field by field even though the same PR just refactored the ORM entity to delegate to ExpressCheckoutSettings::toArray().
Adding buttonStyle required editing this file (two new lines plus a second null-guard ternary) and the domain toArray() and the entity — three places for one field.
This could be $this->expressCheckoutSettings ? $this->expressCheckoutSettings->toArray() : ['expressCheckoutConfigs' => [], 'buttonStyle' => null] merged with availablePages.
As written, the persisted shape and the webhook GET shape are two independent definitions kept in sync by hand; the next field will drift between them.
There was a problem hiding this comment.
Declined, same reasoning as the guest-response thread: cosmetic duplication with no correctness consequence now that the shape is validated once in the domain. Worth doing the next time this response changes for another reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What is the goal?
Merchants can now style the express checkout button from the Merchant Portal — colours, corner radius, font size and uppercase text. This package carries that configuration from the portal out to the storefront.
References
How is it being implemented?
buttonStylestring is threaded through the settings model, the stored entity, the configuration webhook and both availability responses.Caveats
Nothing bounds the blob's length here. The CDN library caps what it forwards, so an oversized value degrades rather than breaking.
Does it affect (changes or update) any sensitive data?
No. Presentation values a merchant picks for their own storefront button.
How is it tested?
Automated tests, including that an attribute this release does not know survives the round trip untouched, and that one store's style never reaches another.
How is it going to be deployed?
Standard deployment.